Repository navigation
Conversation
The observer reads every image's __llvm_prf_cnts as 8-byte counters. With LLVM's single-byte coverage (-mllvm -enable-single-byte-coverage), counters are one byte, start at 0xFF and are cleared once their code runs, so eight of them shared a word and the recorded deltas wrapped into garbage that Tuist's reader would turn into wrong per-test evidence. discover_images() now checks each instrumented image's profile version, __llvm_profile_raw_version, for VARIANT_MASK_BYTE_COVERAGE, looking the hidden variable up in the image's symbol table, and, for images stripped of it, whether the counters section is a whole number of 8-byte counters. A hit stops recording for the process, as any other failure does, so a reader finds no output instead of wrong records. Fixtures/ByteCoverage builds an Objective-C bundle and the C code it tests with 8-byte counters, single-byte counters, and single-byte counters stripped of local symbols. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Member
Author
|
Closing for now: single-byte counters need an explicit -mllvm -enable-single-byte-coverage on C/Objective-C code (swiftc ignores it), so nobody hits this today. The branch fix/single-byte-counters is kept; reopen if Xcode's default or a project's flags change. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The observer now spots images built with LLVM's single-byte coverage counters and records nothing for any process that loads one. Before, it wrote records whose deltas were wrong.
has_byte_counters()runs once per instrumented image indiscover_images(). On a hit,discover_images()fails the way it does for any other image it can't handle:stop_recording()removesimages.tsvandrecords.binfor the process, and the tests run as usual.Fixtures/ByteCoverage(scripted, likeFixtures/StaticArchive): an Objective-C XCTest bundle plus the C code it tests, built three ways. A new parameterized test,recordsNothingForAnImageWithSingleByteCounters, checks each build.No new environment variables, no record format change, and nothing changes for images with 8-byte counters.
Why / root cause
discover_images()andwrite_record()treat every image's__llvm_prf_cntsas an array ofuint64_t. In single-byte mode (-mllvm -enable-single-byte-coverage), clang gives each block a 1-byte counter. The counter starts at0xFFand is set to0once the block runs. Read as words, 8 counters share oneuint64_t, and clearing any of them makes the word smaller, socurrent - snapshotwraps around. Here is whatmain's observer writes for the new fixture'sbytebuild:Those indices aren't counters, and those values aren't counts. Tuist's reader would turn them into wrong per-test evidence: either evidence it can't map, or lines evaluated from garbage expression values.
How it's detected
__llvm_profile_raw_versionholds the same version the profile runtime writes into the.profrawheader. ItsVARIANT_MASK_BYTE_COVERAGEbit (1 << 60) is how LLVM itself, and DataDog'sswift-code-coverage, tell the modes apart. Every instrumented image defines it: clang emits it (in__TEXT,__const) in single-byte mode, and otherwise the profile runtime linked into the image defines it (in__DATA,__data). I checked both a clang binary and the SwiftExamplePackageTestsbundle. The symbol is hidden, so it never reaches dyld's export trie. The observer walks the image'sLC_SYMTABin__LINKEDITfor it once per image, before the first scope. It calls no profile runtime function and writes nothing.strip -x), which no longer have the version variable.Of these, the version is the authoritative signal. The size check is a free backstop. It misses only a stripped single-byte image whose counter count happens to be a multiple of 8, and the README lists that case.
Why stop the whole process instead of leaving the image out
Leaving the image out of
images.tsvwould still produce evidence for the tests in that process, just without the image's code. A test that ran code in that image would then look like a test that didn't. Whether that's safe depends on how Tuist reconciles per-test evidence with the per-target evidence it reads from the processes' profiles, and the observer can't check that. A process with no output is a case every reader already handles: it's the existing failure guarantee. Single-byte mode needs an explicit-mllvmflag, so stopping costs little in practice.Alternatives considered
NumCountersfrom__llvm_prf_dataand compare with the section size. This works without symbols, but the record layout changes with LLVM versions (Tuist's reader andFunctionTable.swiftalready try several). It would tie the observer to LLVM's layout for the first time, and a future layout it misreads could switch recording off for everyone.__llvm_profile_get_version, as DataDog does. It's hidden per image as well, so finding it takes the same symbol-table walk. Reading the variable directly avoids calling into the runtime at all, which this package deliberately never does.0xFF → 0flips). Out of scope: the record layout,kRecordVersionand Tuist's reader would all need to change, and the reader's function table can't place byte counters today.Impact
images.tsvorrecords.bin, so Tuist sees no per-test evidence for them instead of wrong evidence.launches.tsv,unskippable.tsvand skipping are unaffected.Validation
mise run teston Xcode 26.3 (.xcode-version): all 13 tests pass (19 cases, including the UITests fixture on an iOS simulator and the macOS and iOS xcodebuild runs). I ran the task'sswift testdirectly with tuist 4.214.0 first onPATH: undermise run, mise shadows thattuistbinary in this environment, and the 5 tests that need tuist can't start.mise run lint: clean (SwiftFormat, SwiftLint, clang static analyzer).wordrecordstestClassifyandtestSum, each with only its own C function;byteandbyte-strippedrecord nothing. The fixture checks which signal each build exercises: thebytebuild is padded to a whole number of 8-byte words, so only the profile version can catch it, and thebyte-strippedbuild has no version symbol and a section size that isn't a multiple of 8.main's observer, thebytebuild writes the wrapped deltas shown above.recordsNothingForAnImageWithSingleByteCountersandneedsObjCToLinkTheObserverFromAStaticArchivepass. That covers detection with a newer clang and no false positive on a Swift image. No Swift Testing path changed, so I didn't run the full suite on 26.6.🤖 Generated with Claude Code